컴포넌트 : 시스템 레벨의 컴포넌트를 설계 할 때의 재사용성
컴포넌트의 재사용성은 커스터마이징이 쉽냐 여부로 결정된다고 생각한다. 지금 내가 이미 만들어진 걸 활용하지 못한다면, 결국 또 다른 컴포넌트를 정의하거나 Variant로 분기처리해서 쉽게 처리하고 싶은 욕구가 생겨난다. 새롭게 정의하거나 Variant로 각 사용처마다 분기 처리하면 꼭 나중에 유지보수성, 가독성, 확장성에 걸림돌이 된다.
시스템 레벨(Design System, UI Library)에 가까운 컴포넌트를 만들 때는 제어의 역전(IoC)만으로는 부족하다. 아래는 실제로 컴포넌트를 범용적이고 안정적으로 설계하기 위해 추가적으로 고려하고 있는 요소들이다.
1. 다형성(Polymorphism)에 대한 고려
다형성은 “하나의 인터페이스가 여러 구현 형태를 가질 수 있는 구조”다. 객체지향에서는 보통 메서드 오버라이딩을 말하지만, 컴포넌트에서도 같은 인터페이스로 다양한 엘리먼트를 렌더링할 수 있게 만드는 것이라는 의미로 사용된다.
왜 컴포넌트에 다형성이 필요한가?
-
스타일 유지
컴포넌트를
<div>로 래핑하거나 DOM을 중첩하면 원래 적용되던 스타일이 깨질 수 있다.예를 들어 Button 내부에 a를 감싸는 형태는 스타일이나 트리 구조가 달라지면서 CSS가 예상대로 적용되지 않는다.
-
시그니처 통일 & 중복 제거
다형성이 없으면 아래처럼 비슷한 컴포넌트를 따로따로 만들게 된다
<Button />,<LinkButton />,<AnchorButton />이 경우 API가 분산되고, 유지보수가 어려워진다. -
브라우저 표준 준수
브라우저 표준 상,
<button>안에<a>태그를 넣으면 안 된다. 하지만, 디자인 시스템의 버튼이button태그로만 만들어져 있다면, 사용처에서 내부에a태그를 넣으면, form submit 등 브라우저 동작이 예측 불가능해지는 문제가 발생한다.
그렇기 때문에 다형성이 필요하며, 그 다형성은 DOM을 덮어쓰기의 방식으로 구현할 수 있다.
다형성을 구현하는 방법 1 : asChild
asChild는 Radix UI에서 사용하는 방식이며, 컴포넌트를 중첩시키지 않고 교체한다.
function Button({ asChild, children, ...props }) {
if (asChild) {
const child = React.Children.only(children);
return React.cloneElement(child, { ...props });
}
return <button {...props}>{children}</button>;
}
<Button asChild>
<a href="/home">홈으로</a>
</Button>;
자식 요소를 cloneElement로 복제한 후, 부모 컴포넌트의 props를 자식에게 병합해서 전달하는 방식으로 구현된다.
이렇게 하면, 태그를 래핑하지 않고 children을 바로 렌더링해줄 수 있다.
cloneElement 부하 걱정은 안해도 되나?
cloneElement는 DOM을 복사하는 게 아니다. VDOM을 복사한다. React 엘리먼트(VDOM)는 단순한 JS 객체이기 때문에 얕은 복사 + props 병합 정도의 매우 가벼운 작업이다.
하지만 주의해야 할 점은 있다. 부모 리렌더링 시 cloneElement가 다시 호출되어, 항상 새 엘리먼트가 생긴다.
그래서, 메모이제이션을 하지 않으면 불필요한 리렌더링이 발생할 수 있다. 또, ref는 병합되지 않기 때문에. 디자인 시스템에서는 반드시 forwardRef를 고려해야 한다.
다형성을 구현하는 방법 2 : as Props
as props 방식은 보통 아래처럼 만든다:
function Button({ as: Comp = 'button', ...props }) {
return <Comp {...props} />;
}
하지만 이 방식은 타입 호환성을 매번 처리해야 한다. 예를 들어 a, button, div마다 가능한 props가 다르기 때문에 타입 유추가 매우 어렵다. 그래서 asChild 패턴이 더 현실적인 선택이다.
2. Controlled vs Uncontrolled
Controlled는 사용처에서 상태를 제어하는 방식이고,
Uncontrolled는 컴포넌트 내부 상태로 알아서 제어되거나 또는 DOM이 상태를 관리하는 방식입니다.
"입력 컴포넌트를 State로 처리하냐, DOM에서 처리한 후 onSubmit에서 가져오냐"의 고민이 바로 Controlled - UnControlled에 대한 고민이다. 하지만, 일반 컴포넌트에서도 이를 고려해야 한다.
const Controlled = () => {
const [isOpen, setIsOpen] = useState(false);
return(
<>
<Button onClick={()=>setIsOpen(true)}>Custom Button</Button>
<Modal isOpen={isOpen}>
<Modal.Trigger>click me</Modal.Trigger>
<Modal.Content>Content</Modal.Content>
</Modal>
</>
)
};
const UnControlled = () => {
return(
<Modal>
<Modal.Trigger>click me</Modal.Trigger>
<Modal.Content>Content</Modal.Content>
</Modal>
)
};
<Controlled> 컴포넌트는 디자인 시스템의 컴포넌트를 외부에서 제어하도록 커스텀하고 싶어하며,
<UnControlled> 컴포넌트는 그냥 알아서 동작하길 바란다.
이런 범용성을 위해, 디자인 시스템 레벨의 컴포넌트는 Controlled-UnControlled를 고민해서 구현되어야 한다.
이를 위해 useControllableState 같은 훅을 만들어 아래처럼 처리한다.
function useControllableState({ value, defaultValue, onChange }) {
const [internal, setInternal] = useState(defaultValue);
const isControlled = value !== undefined;
const current = isControlled ? value : internal;
const setValue = (v) => {
if (!isControlled) setInternal(v);
onChange?.(v);
};
return [current, setValue];
}
이렇게 하면 value가 존재할 때는 Controlled로, 없을 때는 Uncontrolled로 자연스럽게 동작한다.
3. data- attribute
시스템 레벨의 컴포넌트는 내부 상태를 CSS에서 자연스럽게 접근할 수 있어야 한다. 이래야 커스터마이징이 자유로워진다. 디자인 시스템 컴포넌트를 구현할 때는, 어떤 상태를 내려줘야 커스터마이징이 쉬울지 판단해야한다.
이때 가장 실용적인 방법이 data-* attribute를 활용하는 것이다.
const Dropdown = ({children}) => {
const [isOpen, setIsOpen] = useState(false)
return <DropdownRoot data-isOpen={isOpen}>{children}</DropdownRoot>
}
<Dropdown>
<div className="group:data-[isOpen=true]:scale-110">Content</div>
</Dropdown>
data-*는 컴포넌트 외부에서 상태를 readonly로 접근할 수 있게 해주는 것과 같다고 생각한다.
별도의 CSS 셀렉터를 만들지 않아도 스타일 커스터마이징이 쉬워지며 확장성이 높아진다.
결론
이런 요소들을 고려하면, 단순한 UI 컴포넌트를 넘어 경험이 쌓일수록 유지보수가 쉬운 “시스템 컴포넌트”를 만들 수 있다.